Skip to content

feat(compliance): add status transition guards with pre-flight reads - #171

Merged
El-swaggerito merged 1 commit into
Axionvera:mainfrom
Fury03:feat/compliance-status-transition-guards
Jul 29, 2026
Merged

feat(compliance): add status transition guards with pre-flight reads#171
El-swaggerito merged 1 commit into
Axionvera:mainfrom
Fury03:feat/compliance-status-transition-guards

Conversation

@Fury03

@Fury03 Fury03 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #27

The compliance lifecycle already rejected illegal status changes, but it could not explain a rejection ahead of time. A client had three bad options: re-implement the rules (two copies of a compliance-critical rule set, free to drift), submit and translate the revert (burns a transaction, and a bare Unauthorized cannot distinguish "you need a role" from "this address is frozen and only the admin can lift it"), or read is_compliance_transition_allowed (matrix-only — it ignores the caller, the pause, and the admin-only exit from Blocked, so true did not mean the call would succeed).

This PR adds a guard layer that closes the gap by making one evaluation serve both purposes: the pre-flight reads and every state-changing compliance call reach their verdict through the same function. A pre-flight verdict cannot disagree with enforcement, because there is nothing to disagree with.

What was added

src/compliance_guards.rs — the ordered guard chain, evaluated by a function that never panics and never writes:

# Guard Reason on failure Error
1 Contract is initialized NotInitialized 2000
2 Contract is not paused ContractPaused 3004
3 Caller may act on the current status CallerUnauthorized / BlockedRequiresAdmin 3000
4 Requested status differs from current StatusUnchanged 4007
5 Target is not Unknown TargetUnknownForbidden 4006
6 from -> to is in the transition matrix TransitionForbidden 4006
batch Address appears once per batch DuplicateUserInBatch 4006

Two ordering choices are deliberate and documented: pause before authority (a paused contract reports the pause to everyone and never leaks whether the caller would otherwise have qualified) and authority before the matrix (an unauthorized caller learns nothing about edges for an address they may not touch).

BlockedRequiresAdmin is separated from CallerUnauthorized. Both map to Unauthorized (3000) on-chain, but the remediation differs — "escalate to the admin" vs. "request a role". A client that collapses them tells a properly-credentialed officer to ask for a permission they already hold.

Three pure readscheck_compliance_transition, get_compliance_transition_guard, check_compliance_batch. No authorization, no writes, no events, callable while paused. ComplianceTransitionCheck carries the resolved current_status (so a client cannot race a separate status read against it) and a pre-resolved error_code.

compliance.rs refactored to use the same evaluation. Backwards compatibility is exact: availability/authorization failures still panic, rule violations still return typed Err, and the tolerant legacy wrappers (whitelist_user / revoke_whitelist) keep their idempotent no-op behaviour while still being unable to lift a freeze.

Notes for the reviewer

  • Two pre-existing failures on main are fixed here so the suite is green. They are unrelated to this issue and I kept them minimal: (1) fixtures/sdk/01-compliance.json had drifted and was regenerated with make update-fixtures; (2) fixture_errors asserted AssetNotActive (6000) and AssetLifecyclePaused (6001) for mint attempts that the contract already reports as 7002 / 7000docs/error-codes.md already documents 6000 as "Reserved. Superseded by 7000–7002; no longer emitted", so the expectations were stale, not the contract. cargo fmt also normalized src/config.rs / src/config_test.rs, which were unformatted on main. The same three fixes appear in the PR for Implement issuer role separation #28; whichever merges second can drop them.
  • No behaviour change to any existing entrypoint — all 180 pre-existing tests pass untouched.

Test Evidence

cargo test                                     197 passed; 0 failed   (180 pre-existing + 17 new)
cargo test --test sdk_fixtures                  10 passed; 0 failed
cargo fmt --all -- --check                      clean
cargo build --target wasm32v1-none --release    ok

The load-bearing tests assert agreement rather than a hardcoded expectation, so they fail if the read path and the write path ever diverge: all 5 × 5 source/target edges are walked three times (officer, admin, unauthorized caller), each on a fresh deployment, comparing the pre-flight verdict against the real submission's outcome, error code, and resulting status.

Completion Table

Acceptance Criterion Status Implementation Evidence Test Evidence Documentation Impact
AC 1: Compliance status transition guards is implemented or clearly specified Complete src/compliance_guards.rsevaluate_from_status() (ordered guard chain), require_transition() (enforcement), check_transition() / check_batch() (pre-flight); compliance.rs::validate_transition() now delegates to it test_guard_matches_enforcement_for_every_edge_as_officer, ..._as_admin, ..._as_unauthorized_callersrc/test.rs docs/compliance-transition-guards.md added
AC 2: Relevant edge cases and failure states are handled Complete Uninitialized contract, global pause, wrong-scoped role, admin-only exit from Blocked, self-transitions, unreachable Unknown target, forbidden edges, duplicate batch entries, empty batch, role revoked mid-flight test_guard_reports_not_initialized_instead_of_panicking, test_guard_reports_pause_ahead_of_authority, test_guard_reports_blocked_requires_admin_not_generic_unauthorized, test_guard_reports_status_unchanged_for_every_self_edge, test_guard_reports_target_unknown_as_its_own_reason, test_batch_guard_flags_duplicate_addresses, test_batch_guard_accepts_an_empty_batch, test_guard_verdict_tracks_role_revocation Guard-chain and reason-code tables in docs/compliance-transition-guards.md
AC 3: Security and compliance-sensitive assumptions are documented Complete Guard ordering (pause before authority; authority before matrix); admin-only unblock; reads leak status by design; no require_auth in a read test_guard_reads_never_mutate_state (no state change, no events), test_guard_accepts_emergency_officer_and_rejects_asset_manager "Security and compliance assumptions" section (6 numbered assumptions) + not-legal-advice notice in docs/compliance-transition-guards.md
AC 4: Tests, fixtures, or review checklists are added Complete 17 new tests under COMPLIANCE STATUS TRANSITION GUARDS in src/test.rs; 2 new SDK fixture scenarios cargo test 197 passed; fixtures/sdk/01-compliance.jsoncheck-compliance-transition-allowed, check-compliance-transition-blocked-requires-admin Test-coverage table in docs/compliance-transition-guards.md
AC 5: README or docs link to the new guidance Complete README "Security & Compliance" list entry; cross-links added from compliance-lifecycle.md and compliance-status-transitions.md N/A — documentation only README.md, docs/compliance-lifecycle.md, docs/compliance-status-transitions.md
AC 6: The change is compatible with the rest of the Aegis ecosystem Complete compliance.transition_guards capability + compliance_transition_guards registry key; CAPABILITY_SCHEMA_VERSION 3 → 4; TransitionGuard variants documented as append-only ABI; no existing entrypoint changed All 180 pre-existing tests pass unmodified; capability assertions updated docs/capabilities.md (field, registry key, version note)

Contributor Self-Assessment

  • Scope Confirmation: Changes match issue scope. The three pre-existing repo fixes are called out above.
  • Test Evidence: All tests pass locally (207 total across both suites).
  • CI Status: fmt-check, clippy (no new warnings from the new module), test, and the wasm release build all pass locally.
  • Known Limitations: A guard verdict is point-in-time and not a reservation; the guard does not evaluate require_auth. Both are documented as explicit assumptions in docs/compliance-transition-guards.md.
  • Acceptance Criteria: Every criterion verified in the table above.

Protocol-level compliance controls only. Nothing here is legal or financial advice — see docs/legal-boundary-disclaimer.md.

Adds a guard layer over the compliance lifecycle so a status change can be
evaluated — and explained — before it is submitted, using the same code the
write path enforces.

- src/compliance_guards.rs: ordered guard chain (initialized, not paused,
  caller authority, no-op, target-not-Unknown, transition matrix) evaluated by
  a function that never panics and never writes, plus the typed
  `TransitionGuard` reasons and their error-code mapping.
- compliance.rs now reaches its verdict through the same evaluation, so a
  pre-flight verdict and enforcement cannot drift. Failure shapes (panic vs.
  typed Err) and the tolerant legacy wrappers are unchanged.
- New reads: check_compliance_transition, get_compliance_transition_guard,
  check_compliance_batch. All are pure, stay callable while paused, and emit
  no events.
- BlockedRequiresAdmin is reported separately from CallerUnauthorized: both
  map to 3000, but one needs an escalation and the other a role.
- 17 tests asserting the guard and the contract agree across all 25 edges for
  officer, admin, and unauthorized callers, plus pause ordering, uninitialized
  reads, role revocation, batch atomicity, and duplicate detection.
- docs/compliance-transition-guards.md with the guard chain, reason codes,
  security assumptions, and client guidance; README, capabilities registry
  (schema v4), and SDK fixtures updated.
@El-swaggerito
El-swaggerito merged commit 2abb685 into Axionvera:main Jul 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add compliance status transition guards

2 participants